/* The Founder's Break - nav state, scroll reveal, live countdown, RSVP submit. No scroll listeners: nav state and reveal both ride IntersectionObserver. */ (function () { "use strict"; var TZ = "America/New_York"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* ── Nav background once the page has moved off the top ── */ var nav = document.getElementById("nav"); var sentinel = document.createElement("div"); sentinel.style.cssText = "position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none"; document.body.prepend(sentinel); new IntersectionObserver(function (entries) { nav.classList.toggle("scrolled", !entries[0].isIntersecting); }).observe(sentinel); /* ── Scroll reveal: sequences sections in as they arrive ── */ var reveals = document.querySelectorAll(".reveal"); if (reduceMotion) { reveals.forEach(function (el) { el.classList.add("in"); }); } else { var io = new IntersectionObserver(function (entries, obs) { entries.forEach(function (entry, i) { if (!entry.isIntersecting) return; setTimeout(function () { entry.target.classList.add("in"); }, Math.min(i, 4) * 70); obs.unobserve(entry.target); }); }, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" }); reveals.forEach(function (el) { io.observe(el); }); } /* ── Live countdown ────────────────────────────────────── Mirrors the rule in views/index.ejs so the clock keeps itself honest without a reload: once an event ends, it rolls to the following week on its own, skipping any cancelled date. */ var el = document.getElementById("countdown"); if (el) startCountdown(el); function etParts(date) { // "2026-08-27" plus the weekday index, both in Eastern time. var ymd = new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", }).format(date); var wd = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "short" }).format(date); var order = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; return { iso: ymd, day: order[wd] }; } function etOffsetHours(iso) { var label = new Intl.DateTimeFormat("en-US", { timeZone: TZ, timeZoneName: "shortOffset" }) .format(new Date(iso + "T12:00:00Z")); var m = label.match(/GMT([+-]?\d{1,2})/); return m ? parseInt(m[1], 10) : -5; } function instantFor(iso, hour) { var p = iso.split("-").map(Number); return new Date(Date.UTC(p[0], p[1] - 1, p[2], hour - etOffsetHours(iso), 0, 0)); } function startCountdown(root) { var weekday = Number(root.dataset.weekday); var startHour = Number(root.dataset.startHour); var endHour = Number(root.dataset.endHour); var first = root.dataset.first; var cancelled = (root.dataset.cancelled || "").split(",").filter(Boolean); var nums = {}; root.querySelectorAll("[data-unit]").forEach(function (n) { nums[n.dataset.unit] = n; }); var label = document.getElementById("countdown-label"); var when = document.getElementById("countdown-when"); var clock = document.getElementById("countdown-clock"); function nextOccurrence(now) { var today = etParts(now); var parts = today.iso.split("-").map(Number); var cur = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])); var firstParts = first.split("-").map(Number); var firstDate = new Date(Date.UTC(firstParts[0], firstParts[1] - 1, firstParts[2])); if (cur < firstDate) cur = firstDate; var iso = function (d) { return d.toISOString().slice(0, 10); }; while (cur.getUTCDay() !== weekday) cur.setUTCDate(cur.getUTCDate() + 1); // Only roll past today once the night is actually over. if (iso(cur) === today.iso && now >= instantFor(today.iso, endHour)) { cur.setUTCDate(cur.getUTCDate() + 7); } while (cancelled.indexOf(iso(cur)) !== -1) cur.setUTCDate(cur.getUTCDate() + 7); return iso(cur); } function render() { var now = new Date(); var iso = nextOccurrence(now); var start = instantFor(iso, startHour); var end = instantFor(iso, endHour); var pretty = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "long", month: "long", day: "numeric", }).format(start); if (now >= start && now < end) { root.classList.add("is-live"); label.textContent = "Happening right now"; clock.hidden = true; when.textContent = "Until " + fmtTime(end) + " tonight. Come find us at the tables."; return; } root.classList.remove("is-live"); clock.hidden = false; var diff = Math.max(0, start - now); var total = Math.floor(diff / 1000); set("days", Math.floor(total / 86400)); set("hours", Math.floor(total / 3600) % 24); set("minutes", Math.floor(total / 60) % 60); set("seconds", total % 60); label.textContent = total < 86400 ? "The Break is tonight" : "Until the next Break"; when.textContent = pretty + ", " + fmtTime(start) + " at Silver Q"; } function fmtTime(d) { return new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "numeric", minute: "2-digit" }).format(d); } function set(unit, value) { var node = nums[unit]; if (!node) return; var text = unit === "days" ? String(value) : String(value).padStart(2, "0"); if (node.textContent === text) return; node.textContent = text; if (reduceMotion) return; node.classList.remove("tick"); void node.offsetWidth; // restart the animation node.classList.add("tick"); } render(); var timer = setInterval(render, 1000); // A backgrounded tab throttles the interval; catch up the moment it returns. document.addEventListener("visibilitychange", function () { if (!document.hidden) render(); }); window.addEventListener("pagehide", function () { clearInterval(timer); }); } /* ── Funnel milestones ─────────────────────────────────── The Corben tracker injected into every site already records pageviews, clicks on [data-track], form_start, form_submit, scroll depth and engagement time. These are the steps it cannot see on its own: reaching the RSVP block, and whether the submission actually succeeded. Same public /analytics/events endpoint the tracker posts to. */ var SITE_ID = (document.currentScript && document.currentScript.dataset.site) || (document.querySelector('script[data-site]') || {}).dataset; if (SITE_ID && typeof SITE_ID === "object") SITE_ID = SITE_ID.site; function track(type, data) { if (!SITE_ID) return; var body = JSON.stringify({ site_id: SITE_ID, events: [{ event_type: type, page_path: location.pathname, event_data: data || {}, ts: Date.now() }], }); // keepalive so an event fired as the tab closes still lands. try { fetch("https://api.corben.world/analytics/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: body, keepalive: true, mode: "cors", }).catch(function () {}); } catch (e) {} } // Did they ever actually reach the RSVP block? Fires once. var rsvpSection = document.getElementById("rsvp"); if (rsvpSection) { var seen = new IntersectionObserver(function (entries, obs) { if (!entries[0].isIntersecting) return; track("rsvp_reached", { source: new URLSearchParams(location.search).get("utm_source") || "direct" }); obs.disconnect(); }, { threshold: 0.3 }); seen.observe(rsvpSection); } /* ── RSVP ── */ var form = document.getElementById("rsvp-form"); if (!form) return; var submit = document.getElementById("rsvp-submit"); var status = document.getElementById("form-status"); var done = document.getElementById("rsvp-done"); var live = document.getElementById("rsvp-live"); var nameField = document.getElementById("f-name"); var emailField = document.getElementById("f-email"); function setError(input, message) { var field = input.closest(".field"); field.classList.toggle("has-error", Boolean(message)); var slot = field.querySelector(".field-error"); if (slot) slot.textContent = message || ""; input.setAttribute("aria-invalid", message ? "true" : "false"); } function validate() { var ok = true; if (!nameField.value.trim()) { setError(nameField, "We need a name to put on the list."); ok = false; } else setError(nameField, ""); var email = emailField.value.trim(); if (!email) { setError(emailField, "We need an email to send the reminder to."); ok = false; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) { setError(emailField, "That email does not look right."); ok = false; } else setError(emailField, ""); return ok; } [nameField, emailField].forEach(function (input) { input.addEventListener("input", function () { if (input.closest(".field").classList.contains("has-error")) validate(); }); }); form.addEventListener("submit", function (event) { event.preventDefault(); status.textContent = ""; if (!validate()) { form.querySelector(".has-error input").focus(); return; } submit.setAttribute("aria-busy", "true"); var payload = new URLSearchParams(); new FormData(form).forEach(function (value, key) { if (key !== "_redirect") payload.append(key, value); }); fetch(form.action, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", Accept: "application/json", }, body: payload.toString(), }) .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json().catch(function () { return { ok: true }; }); }) .then(function (body) { if (body && body.ok === false) throw new Error(body.error || "rejected"); track("rsvp_completed", { via: "form" }); showDone(); }) .catch(function (err) { track("rsvp_failed", { via: "form", reason: String(err && err.message || "unknown").slice(0, 80) }); submit.removeAttribute("aria-busy"); status.textContent = "That did not go through. Try again, or email contact@onetouch.studio and we will add you by hand."; }); }); function showDone(kind) { // Replace the whole interactive block, not just the form, so the heading // and the QR do not sit stranded above the confirmation. if (live) live.hidden = true; form.hidden = true; done.hidden = false; var h = done.querySelector("h3"), p = done.querySelector("p"), ics = done.querySelector(".ics"); if (kind === "no") { h.textContent = "Marked as not attending."; p.textContent = "You are still on the list. We will send the reminder again next week."; if (ics) ics.hidden = true; } else { if (ics) ics.hidden = false; } done.setAttribute("tabindex", "-1"); done.focus({ preventScroll: true }); } /* No-JS fallback lands back here with ?rsvp=ok after the plain form POST. */ var params = new URLSearchParams(window.location.search); if (params.get("rsvp") === "ok") showDone(); /* One-click answer from the weekly reminder: ?a=yes|no&e=
. This posts to the same /forms/rsvp the form uses rather than opening a new public write endpoint, so there is exactly one path into the pipeline. */ var answer = params.get("a"); if (answer === "yes" || answer === "no") { // URLSearchParams turns "+" into a space, and an address can never contain // a literal space, so putting it back is safe and fixes plus-addressing. var who = (params.get("e") || "").trim().replace(/ /g, "+"); if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(who)) { // Bad or missing address: fall back to the normal form rather than guess. status.textContent = "We could not read that link. Fill this in and you are set."; } else { emailField.value = who; submit.setAttribute("aria-busy", "true"); var oneClick = new URLSearchParams(); oneClick.set("name", params.get("n") || who.split("@")[0]); oneClick.set("email", who); oneClick.set("attending", answer); oneClick.set("source", "reminder-email"); oneClick.set("event_date", form.querySelector('[name="event_date"]').value); fetch(form.action, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", Accept: "application/json", }, body: oneClick.toString(), }) .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); return res.json().catch(function () { return {}; }); }) .then(function (body) { if (body && body.ok === false) throw new Error("rejected"); track("rsvp_completed", { via: "reminder-email", answer: answer }); showDone(answer); }) .catch(function (err) { track("rsvp_failed", { via: "reminder-email", answer: answer, reason: String(err && err.message || "unknown").slice(0, 80) }); submit.removeAttribute("aria-busy"); status.textContent = "That link did not go through. Hit RSVP below and you are set."; }); } } })();