founders-break/public/js/main.js

290 lines
11 KiB
JavaScript

/* 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); });
}
/* ── 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");
showDone();
})
.catch(function () {
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=<address>.
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");
showDone(answer);
})
.catch(function () {
submit.removeAttribute("aria-busy");
status.textContent = "That link did not go through. Hit RSVP below and you are set.";
});
}
}
})();