Link previews no longer submit answers. Failed queue attempts retain reusable drafts; recipient queues prevent duplicate retries. Tested: 18 isolated regressions pass; 13 fail on baseline. AI-Agent: Codex (OpenAI) AI-Session: 01a14186-e2ee-77b1-9526-a32c58cd6a00
364 lines
15 KiB
JavaScript
364 lines
15 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); });
|
|
}
|
|
|
|
|
|
/* ── 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();
|
|
|
|
/* Email links select an answer, but only a trusted confirmation click
|
|
submits it. Security scanners can open both links without changing RSVPs. */
|
|
var answer = params.get("a");
|
|
if (answer === "yes" || answer === "no") {
|
|
var who = (params.get("e") || "").trim().replace(/ /g, "+");
|
|
var currentEvent = form.querySelector('[name="event_date"]').value;
|
|
var linkedEvent = params.get("d");
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(who)) {
|
|
status.textContent = "We could not read that link. Fill this in and you are set.";
|
|
} else if (linkedEvent && linkedEvent !== currentEvent) {
|
|
status.textContent = "That invitation is for a different week. Use this form to RSVP for the date shown above.";
|
|
} else {
|
|
emailField.value = who;
|
|
form.hidden = true;
|
|
var panel = document.createElement("div");
|
|
panel.id = "email-rsvp-confirm";
|
|
var message = document.createElement("p");
|
|
var prettyEvent = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: "UTC", weekday: "long", month: "long", day: "numeric",
|
|
}).format(new Date(currentEvent + "T12:00:00Z"));
|
|
message.textContent = "Confirm your RSVP for " + prettyEvent + " as " + who + ". Nothing is saved until you confirm.";
|
|
panel.appendChild(message);
|
|
var feedback = document.createElement("p");
|
|
feedback.setAttribute("role", "status");
|
|
feedback.setAttribute("aria-live", "polite");
|
|
var buttons = [];
|
|
var pending = false;
|
|
[answer, answer === "yes" ? "no" : "yes"].forEach(function (choice, i) {
|
|
var button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "confirm-rsvp-" + choice;
|
|
button.className = "btn btn-block" + (i === 0 ? " btn-primary" : "");
|
|
button.textContent = choice === "yes" ? "Confirm: I am coming" : "Confirm: I cannot make it";
|
|
button.addEventListener("click", function (event) {
|
|
if (!event.isTrusted || pending) return;
|
|
pending = true;
|
|
feedback.textContent = "Saving your answer...";
|
|
buttons.forEach(function (b) { b.disabled = true; });
|
|
var payload = new URLSearchParams();
|
|
payload.set("name", params.get("n") || who.split("@")[0]);
|
|
payload.set("email", who);
|
|
payload.set("attending", choice);
|
|
payload.set("source", "reminder-email");
|
|
payload.set("rsvp_confirmed", "yes");
|
|
payload.set("event_date", currentEvent);
|
|
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(); })
|
|
.then(function (body) {
|
|
if (!body || body.ok !== true) throw new Error("rejected");
|
|
track("rsvp_completed", { via: "reminder-email", answer: choice });
|
|
["a", "e", "n", "d"].forEach(function (key) { params.delete(key); });
|
|
var query = params.toString();
|
|
window.history.replaceState(null, "", window.location.pathname + (query ? "?" + query : "") + "#rsvp");
|
|
showDone(choice);
|
|
})
|
|
.catch(function (err) {
|
|
pending = false;
|
|
buttons.forEach(function (b) { b.disabled = false; });
|
|
feedback.textContent = "That did not go through. Please try confirming again.";
|
|
track("rsvp_failed", { via: "reminder-email", answer: choice, reason: String(err && err.message || "unknown").slice(0, 80) });
|
|
});
|
|
});
|
|
buttons.push(button);
|
|
panel.appendChild(button);
|
|
});
|
|
panel.appendChild(feedback);
|
|
live.insertBefore(panel, form);
|
|
}
|
|
}
|
|
|
|
})();
|