236 lines
8.8 KiB
JavaScript
236 lines
8.8 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 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() {
|
|
form.hidden = true;
|
|
done.hidden = false;
|
|
done.setAttribute("tabindex", "-1");
|
|
done.focus({ preventScroll: true });
|
|
}
|
|
|
|
/* No-JS fallback lands back here with ?rsvp=ok after the plain form POST. */
|
|
if (new URLSearchParams(window.location.search).get("rsvp") === "ok") showDone();
|
|
})();
|