PageSourceSearch

https://brandnewchurch.com/js/main.js?v=27

js brandnewchurch.com collected 2026-09-24 11:39:02 UTC 21,036 bytes, 452 lines download raw bytes

1/* Brand New Church - site behaviors + hot-swap rendering */
2
3(function () {
4  "use strict";
5
6  var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
7
8  /* ── Nav: frosted bar after scroll ─────────────────────────────────── */
9
10  var nav = document.querySelector(".nav");
11
12  function onScrollNav() {
13    if (!nav) return;
14    if (window.scrollY > 24) nav.classList.add("solid");
15    else nav.classList.remove("solid");
16  }
17  window.addEventListener("scroll", onScrollNav, { passive: true });
18  onScrollNav();
19
20  /* ── Mobile menu ───────────────────────────────────────────────────── */
21
22  var burger = document.querySelector(".nav-burger");
23  if (burger) {
24    burger.addEventListener("click", function () {
25      var open = document.body.classList.toggle("menu-open");
26      burger.setAttribute("aria-expanded", open ? "true" : "false");
27    });
28    document.querySelectorAll(".menu-overlay a").forEach(function (a) {
29      a.addEventListener("click", function () {
30        document.body.classList.remove("menu-open");
31        burger.setAttribute("aria-expanded", "false");
32      });
33    });
34  }
35
36  /* ── Cinematic hero scroll (home only) ─────────────────────────────── */
37
38  var hero = document.querySelector(".hero");
39  var heroImg = document.querySelector(".hero-img");
40  var heroCopy = document.querySelector(".hero-copy");
41  var heroTimes = document.querySelector(".hero-times");
42  var cue = document.querySelector(".scroll-cue");
43
44  if (hero && heroImg && !reduceMotion) {
45    var ticking = false;
46    // Cache the scroll runway so we never read layout (offsetHeight) inside the
47    // scroll loop; reading it per frame forces a reflow and causes jitter.
48    var runway = 0;
49    var measureHero = function () { runway = hero.offsetHeight - window.innerHeight; };
50
51    var updateHero = function () {
52      ticking = false;
53      if (runway <= 0) return;
54      var p = window.scrollY / runway;
55      p = p < 0 ? 0 : p > 1 ? 1 : p;
56
57      // Slow push-in on the campus photo (translate3d keeps it on the GPU)
58      heroImg.style.transform = "scale(" + (1 + p * 0.09) + ") translate3d(0," + (p * -14) + "px,0)";
59
60      // Headline drifts up into the sky and fades
61      if (heroCopy) {
62        heroCopy.style.transform = "translate3d(0," + (p * -70) + "px,0)";
63        heroCopy.style.opacity = String(1 - Math.max(0, (p - 0.35)) * 2.1);
64      }
65      if (heroTimes) heroTimes.style.opacity = String(1 - p * 2.4);
66      if (cue) cue.style.opacity = String(1 - p * 3);
67    };
68
69    var onHeroScroll = function () {
70      if (!ticking) { ticking = true; window.requestAnimationFrame(updateHero); }
71    };
72
73    window.addEventListener("scroll", onHeroScroll, { passive: true });
74    window.addEventListener("resize", function () { measureHero(); updateHero(); }, { passive: true });
75    measureHero();
76    updateHero();
77  }
78
79  /* ── Scroll reveals ────────────────────────────────────────────────── */
80
81  var revealEls = document.querySelectorAll(".reveal");
82  if ("IntersectionObserver" in window && !reduceMotion) {
83    var io = new IntersectionObserver(function (entries) {
84      entries.forEach(function (e) {
85        if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); }
86      });
87    }, { rootMargin: "0px 0px -8% 0px", threshold: 0.08 });
88    revealEls.forEach(function (el) { io.observe(el); });
89  } else {
90    revealEls.forEach(function (el) { el.classList.add("in"); });
91  }
92
93  /* ── Marquee: duplicate track content for a seamless loop ──────────── */
94
95  document.querySelectorAll(".marquee-row .track").forEach(function (track) {
96    track.innerHTML += track.innerHTML;
97  });
98
99  /* ── Hot-swap rendering from js/content.js ─────────────────────────── */
100
101  var C = window.BNC || {};
102
103  function el(tag, cls, html) {
104    var n = document.createElement(tag);
105    if (cls) n.className = cls;
106    if (html !== undefined) n.innerHTML = html;
107    return n;
108  }
109
110  function esc(s) {
111    return String(s == null ? "" : s)
112      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
113      .replace(/"/g, "&quot;");
114  }
115
116  function btnClass(style) {
117    return style === "ghost" ? "btn btn-ghost" : style === "solid" ? "btn btn-solid" : "btn btn-white";
118  }
119
120  /* Current series panel */
121  var seriesRoot = document.getElementById("series-root");
122  if (seriesRoot && C.series) {
123    var s = C.series;
124    var art = s.logo
125      ? '<div class="panel-art reveal"><img decoding="async" src="' + esc(s.logo) + '" alt="' + esc(s.title) + ' series artwork"></div>'
126      : "";
127    var titleBlock = s.logo ? "" : "<h3>" + esc(s.title) + "</h3>";
128    var ctas = (s.ctas || []).map(function (c) {
129      return '<a class="' + btnClass(c.style) + '" href="' + esc(c.href) + '"' +
130        (/^https?:/.test(c.href) ? ' target="_blank" rel="noopener"' : "") + ">" + esc(c.label) + "</a>";
131    }).join("");
132
133    if (s.artworkFillsFrame) {
134      // .series-split lays its children out as flex rows so panels f
134ill their
135      // grid cell; this variant adds a second child (the buttons) that has to
136      // stack under the frame instead of sitting beside it.
137      seriesRoot.classList.add("series-root--artwork");
138      // The artwork is a finished, self-contained graphic (it already carries
139      // the series name and its own background) rather than the usual photo +
140      // transparent-logo pairing. So it becomes the frame itself: rendered in
141      // flow at full width, which lets the panel take the image's own height -
142      // no cropping at any viewport, and no hardcoded aspect ratio. No scrim
143      // either; that exists to keep white text legible over a photo, and here
144      // it would just grey down a clean graphic. The buttons move below the
145      // frame, where they can't sit on top of the artwork's own type.
146      seriesRoot.innerHTML =
147        '<div class="panel series panel--artwork reveal">' +
148          '<img class="panel-artwork" decoding="async" src="' + esc(s.image) + '" alt="' + esc(s.title) + '">' +
149        "</div>" +
150        '<div class="series-ctas reveal" style="--d:.1s">' + ctas + "</div>";
151    } else {
152      seriesRoot.innerHTML =
153        '<div class="panel series">' +
154          '<div class="panel-bg"><img decoding="async" src="' + esc(s.image) + '" alt=""></div>' +
155          '<div class="panel-scrim"></div>' +
156          '<div class="panel-content">' +
157            art +
158            '<div class="panel-copy reveal" style="--d:.1s">' +
159              (s.badgeImage
160                ? '<img decoding="async" class="panel-badge-img" src="' + esc(s.badgeImage) + '" alt="">'
161                : (s.badge ? '<span class="panel-badge">' + esc(s.badge) + "</span>" : "")) +
162              titleBlock +
163              (s.tagline ? "<p>" + esc(s.tagline) + "</p>" : "") +
164              '<div class="panel-ctas">' + ctas + "</div>" +
165            "</div>" +
166          "</div>" +
167        "</div>";
168    }
169  }
170
171  /* Side promo panel, sits beside the series. The whole frame is the link and
172     the "button" is a span, so there is no anchor nested inside an anchor. */
173  var promoRoot = document.getElementById("promo-root");
174  if (promoRoot && C.promo && C.promo.image) {
175    var pr = C.promo;
176    var dark = pr.tone === "dark";
177    var external = /^https?:/.test(pr.href || "");
178
179    promoRoot.innerHTML =
180      '<a class="panel promo' + (dark ? " promo-dark" : "") + ' reveal" style="--d:.1s" href="' + esc(pr.href || "#") + '"' +
181        (external ? ' target="_blank" rel="noopener"' : ' download') + ">" +
182        '<div class="panel-bg"><img decoding="async" src="' + esc(pr.image) + '" alt="' + esc(pr.alt || "") + '"></div>' +
183        '<div class="promo-scrim"></div>' +
184        '<div class="promo-content">' +
185          '<span class="btn ' + (dark ? "btn-white" : "btn-solid") + '">' + esc(pr.cta || "Download") + "</span>" +
186        "</div>" +
187      "</a>";
188  }
189
190  // No promo configured: let the series panel span the full width again.
191  var splitEl = document.querySelector(".series-split");
192  if (splitEl && (!C.promo || !C.promo.image)) splitEl.classList.add("series-split--solo");
193
194  /* Events: one featured banner + cards */
195  var featRoot = document.getElementById("event-featured-root");
196  var gridRoot = document.getElementById("event-grid-root");
197  if (C.events && (featRoot || gridRoot)) {
198    var feats = C.events.filter(function (e) { return e.featured; });
199    var rest = C.events.filter(function (e) { return !e.featured; });
200
201    if (featRoot && feats.length) {
202      featRoot.innerHTML = feats.map(function (e) {
203        var logo = e.logo
204          ? '<div class="panel-art reveal"><img decoding="async" src="' + esc(e.logo) + '" alt="' + esc(e.title) + '"></div>'
205          : "";
206        // Same rule as the series panel: when there is a logo it already
207        // carries the name, so printing the title too just says it twice.
208        var featTitle = e.logo ? "" : "<h3>" + esc(e.title) + "</h3>";
209        // Only send visitors to a new tab when the link actually leaves us.
210        var featExternal = /^https?:/.test(e.href || "");
211
212        return '<div class="panel">' +
213          '<div class="panel-bg"><img decoding="async" src="' + esc(e.image) + '" alt=""></div>' +
214          '<div class="panel-scrim"></div>' +
215          '<div class="panel-content">' +
216            '<div class="panel-copy reveal">
216' +
217              (e.badge ? '<span class="panel-badge">' + esc(e.badge) + "</span>" : "") +
218              featTitle +
219              (e.blurb ? "<p>" + esc(e.blurb) + "</p>" : "") +
220              '<div class="panel-ctas"><a class="btn btn-white" href="' + esc(e.href) + '"' +
221                (featExternal ? ' target="_blank" rel="noopener"' : "") + ">" + esc(e.cta || "Learn More") + "</a></div>" +
222            "</div>" + logo +
223          "</div></div>";
224      }).join("");
225    }
226
227    if (gridRoot) {
228      gridRoot.innerHTML = rest.map(function (e, i) {
229        // A card can carry a link before it's meant to go live (e.g. tickets
230        // queued ahead of an on-sale time). liveAt is an ISO timestamp; until
231        // it passes, the card renders exactly as if href were never set.
232        var isLive = !e.liveAt || Date.now() >= new Date(e.liveAt).getTime();
233        var href = isLive ? e.href : null;
234        var external = /^https?:/.test(href || "");
235        // Some events have nothing to sign up for. Those render as a plain
236        // card with no link and no call to action, rather than a link that
237        // dumps people on a generic page.
238        var tag = href ? "a" : "div";
239        return "<" + tag + ' class="event-card reveal' + (href ? "" : " is-static") +
240          '" style="--d:' + (i * 0.08) + 's"' +
241          (href ? ' href="' + esc(href) + '"' : "") +
242          (external ? ' target="_blank" rel="noopener"' : "") + ">" +
243          '<div class="event-media"><img decoding="async" src="' + esc(e.image) + '" alt="' + esc(e.title) + '" loading="lazy"></div>' +
244          '<div class="event-body">' +
245            '<span class="event-date">' + esc(e.date || "") + "</span>" +
246            "<h4>" + esc(e.title) + "</h4>" +
247            "<p>" + esc(e.blurb || "") + "</p>" +
248            (href ? '<span class="text-link">' + esc(e.cta || "Details") + ' <span class="chev">›</span></span>' : "") +
249          "</div></" + tag + ">";
250      }).join("");
251    }
252  }
253
254  /* Service times grid */
255  var timesRoot = document.getElementById("times-root");
256  if (timesRoot && C.serviceTimes) {
257    timesRoot.innerHTML = C.serviceTimes.map(function (t, i) {
258      return '<div class="time-card reveal" style="--d:' + (i * 0.05) + 's">' +
259        '<div class="tc-day">' + esc(t.day) + "</div>" +
260        '<div class="tc-name">' + esc(t.name) + "</div>" +
261        '<div class="tc-time">' + esc(t.time) + "</div>" +
262      "</div>";
263    }).join("");
264  }
265
266  /* Hero times pill */
267  var heroTimesText = document.getElementById("hero-times-text");
268  if (heroTimesText && C.heroTimesLine) heroTimesText.textContent = C.heroTimesLine;
269
270  /* Re-observe anything we just injected */
271  if ("IntersectionObserver" in window && !reduceMotion) {
272    var io2 = new IntersectionObserver(function (entries) {
273      entries.forEach(function (e) {
274        if (e.isIntersecting) { e.target.classList.add("in"); io2.unobserve(e.target); }
275      });
276    }, { rootMargin: "0px 0px -8% 0px", threshold: 0.08 });
277    document.querySelectorAll(".reveal:not(.in)").forEach(function (n) { io2.observe(n); });
278  } else {
279    document.querySelectorAll(".reveal").forEach(function (n) { n.classList.add("in"); });
280  }
281
282  /* ── Contact form → Netlify Forms (AJAX, in-page success) ──────────── */
283
284  var contactForm = document.getElementById("contact-form");
285  if (contactForm) {
286    contactForm.addEventListener("submit", function (ev) {
287      ev.preventDefault();
288      var btn = contactForm.querySelector('button[type="submit"]');
289      var note = contactForm.querySelector(".form-note");
290      var body = new URLSearchParams(new FormData(contactForm)).toString();
291
292      if (btn) { btn.disabled = true; btn.dataset.label = btn.textContent; btn.textContent = "Sending…"; }
293      if (note) { note.style.color = ""; note.textContent = "We’ll reply by email as soon as we can."; }
294
295      fetch("/", {
296        method: "POST",
297        headers: { "Content-Type": "application/x-www-form-urlencoded" },
298        body: body
299      }).then(function (res) {
300        if (!res.ok) throw new Error("HTTP " + res.status);
301        contactForm.innerHTML =
302          '<div class="form-success">' +
303            "<h3>Thank you!</h3>" +
304            "<p>Your message is on its way. One of our team members will reach out by email as soon as we can.</p>" +
305          "</div>";
306      }).catch(function () {
307        if (btn) { btn.disabled = false; btn.textContent = btn.dataset.label || "Send Message"; }
308        if (note) {
309          note.style.color = "#c0392b";
310          note.textContent = "Sorry, something went wrong. Please email [email protected] or call (888) 391-2262.";
311        }
312      });
313    });
314  }
315
316  /* ── Midweek schedule: strike out Wednesdays once they have happened ───
317     Each row carries a data-date. A night stays un-struck all day Wednesday
318     and only reads as done from the Thursday, so the current week never looks
319     finished while the service is still going on. Uses the visitor's own
320     local date, which is what they would expect to see. */
321
322  var schedRows = document.querySelectorAll(".sched-list li[data-date]");
323  if (schedRows.length) {
324    var startOfToday = new Date();
325    startOfToday.setHours(0, 0, 0, 0);
326
327    schedRows.forEach(function (li) {
328      var p = li.getAttribute("data-date").split("-");
329      // Build it from parts rather than new Date("2026-08-12"): the string
330      // form is parsed as UTC midnight, which lands on the previous day for
331      // anyone west of Greenwich (including Arkansas).
332      var doneFrom = new Date(+p[0], +p[1] - 1, +p[2]);
333      doneFrom.setDate(doneFrom.getDate() + 1);
334      if (startOfToday >= doneFrom) li.classList.add("is-past");
335    });
336  }
337
338  /* ── Footer year ───────────────────────────────────────────────────── */
339
340  document.querySelectorAll("[data-year]").forEach(function (n) {
341    n.textContent = String(new Date().getFullYear());
342  });
343
344  /* ── Smooth scroll (Lenis): DESKTOP ONLY ──────────────────────────────
345     Interpolates the scroll position so wheel/trackpad input glides instead
346     of jumping in discrete steps. Phones keep their native momentum scroll
347     (better on touch), and it's skipped for reduced-motion users. */
348
349  // Enable on real-pointer devices (mouse/trackpad) regardless of window size,
350  // so a narrow desktop window still gets smooth scroll. Touch phones/tablets
351  // report (pointer: coarse)/(hover: none) and keep native momentum scrolling.
352  var hasFinePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
353
354  if (hasFinePointer && !reduceMotion && typeof window.Lenis === "function") {
355    var lenis = new window.Lenis({
356      lerp: 0.075,           // lower = more glide/fluidity; higher = snappier
357      wheelMultiplier: 0.75, // lower = less distance per wheel notch/flick
358      smoothWheel: true
359    });
360
361    function raf(time) {
362      lenis.raf(time);
363      window.requestAnimationFrame(raf);
364    }
365    window.requestAnimationFrame(raf);
366
367    // Route same-page anchor links (#section) through Lenis so they glide too.
368    // Skips bare "#" links (e.g. the Pushpay campus tiles, which manage their
369    // own clicks) and anything explicitly opted out with data-lenis-external.
370    document.querySelectorAll('a[href^="#"]').forEach(function (link) {
371      var hash = link.getAttribute("href");
372      if (hash.length <= 1 || link.hasAttribute("data-lenis-external")) return;
373      link.addEventListener("click", function (e) {
374        var target = document.querySelector(hash);
375        if (!target) return;
376        e.preventDefault();
377        lenis.scrollTo(target, { offset: -70 });
378      });
379    });
380
381    // When arriving from another page with a #hash (e.g. midweek.html#youth),
382    // glide to it once the layout settles.
383    if (window.location.hash) {
384      var deep = document.querySelector(window.location.hash);
385      if (deep) {
386        window.requestAnimationFrame(function () {
387          lenis.scrollTo(deep, { offset: -70, immediate: true });
388        });
389      }
390    }
391  }
392
393  /* ── Giving page: remove the Pushpay widget's internal scrollbar ────────
394     Pushpay's embedded form renders inside a shadow root and caps its own
395     content area at a fixed height (a ".scollable-content" div with its own
396     overflow-y: auto) — so the form scrolls inside a small box instead of
397     showing in full. That style lives inside their shadow DOM, so it can't be
398     reached with a normal stylesheet selector; we neutralize it in JS with an
399     !important inline override instead. Reruns on every campus
400     switch/reselect, since Pushpay tears down and rebuilds the widget each
401     time. */
402
403  if (document.getElementById("pushpay-embedded-giving")) {
404    // Only touch the element if it isn't already flattened, and never observe
405    // "attributes" on it — since our own fix writes to the style attribute,
406    // watching attributes would make the observer re-trigger itself in an
407    // infinite loop (this froze the tab, worst on Safari). childList/subtree
408    // alone still catches Pushpay replacing the whole node when the form loads.
409    var flattenGivingWidget = function () {
410      var host = document.getElementById("pushpay-embedded-giving");
411      var root = host && host.shadowRoot;
412      var el = root && root.querySelector(".scollable-content");
413      if (el && el.style.maxHeight !== "none") {
414        el.style.setProperty("max-height", "none", "important");
415        el.style.setProperty("overflow-y", "visible", "important");
416        el.style.setProperty("overflow", "visible", "important");
417      }
418    };
419
420    var activeGivingObserver = null;
421
422    // Pushpay rebuilds the shadow DOM in stages (skeleton -> loaded form), so
423    // a one-shot fix can lose the race. Watch the shadow root and reapply the
424    // override for a while after each campus (re)selection.
425    var watchGivingWidget = function () {
426      if (activeGivingObserver) { activeGivingObserver.disconnect(); activeGivingObserver = null; }
427
428      var attachTries = 0;
429      var attachId = setInterval(function () {
430        attachTries++;
431        var host = document.getElementById("pushpay-embedded-giving");
432        var root = host && host.shadowRoot;
433        if (root) {
434          clearInterval(attachId);
435          flattenGivingWidget();
436          var mo = new MutationObserver(flattenGivingWidget);
437          mo.observe(root, { childList: true, subtree: true });
438          activeGivingObserver = mo;
439          window.setTimeout(function () { // widget is static once loaded
440            if (activeGivingObserver === mo) { mo.disconnect(); activeGivingObserver = null; }
441          }, 15000);
442        } else if (attachTries > 60) {
443          clearInterval(attachId); // ~9s safety timeout, no widget host found
444        }
445      }, 150);
446    };
447
448    watchGivingWidget(); // covers a cookie-restored campus on page load
449    document.querySelectorAll('.pushpay-listing-select-menu li a, #pushpay-change-campus-link')
450      .forEach(function (el) { el.addEventListener("click", watchGivingWidget); });
451  }
452})();

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.