One-tap yes/no from the reminder, and a shorter email for returning regulars

This commit is contained in:
Devin Willis 2026-08-26 15:02:11 -04:00
parent ebfc15bd27
commit 1113032c6c
No known key found for this signature in database
GPG key ID: 3776B3C5AFF3FFEA
3 changed files with 150 additions and 33 deletions

View file

@ -118,6 +118,26 @@ function button(label, href) {
'</td></tr></table>'; '</td></tr></table>';
} }
// Yes / No in one tap. The links land on the site, which posts to the same
// /forms/rsvp the form uses, so there is no second public write endpoint.
// {{email}} is a campaign merge field and resolves per recipient.
function answerButtons() {
var yes = SITE + "/?a=yes&e={{email}}#rsvp";
var no = SITE + "/?a=no&e={{email}}#rsvp";
return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 4px;">' +
'<tr>' +
'<td style="border-radius:10px;background:#10b981;">' +
'<a href="' + yes + '" style="display:inline-block;padding:13px 30px;font:700 15px/1 ' + SANS + ';color:#04140f;text-decoration:none;border-radius:10px;">Yes, I am coming</a>' +
'</td>' +
'<td style="width:12px;"></td>' +
'<td style="border-radius:10px;border:1px solid rgba(255,255,255,.18);">' +
'<a href="' + no + '" style="display:inline-block;padding:12px 26px;font:600 15px/1 ' + SANS + ';color:#c2c0d4;text-decoration:none;border-radius:10px;">Not this week</a>' +
'</td>' +
'</tr></table>' +
'<p style="margin:12px 0 0;font:400 13px/1.5 ' + SANS + ';color:#6f6d88;">One tap, nothing to fill in. You are already on the list either way.</p>';
}
// ── main ──────────────────────────────────────────────────────────────── // ── main ────────────────────────────────────────────────────────────────
var force = !!(event && event.force); var force = !!(event && event.force);
var now = etNow(); var now = etNow();
@ -139,8 +159,8 @@ var when = pretty(target);
var body = var body =
'<p style="margin:0;">Tomorrow night. Same tables, same time. Come build something with people who are also building something.</p>' + '<p style="margin:0;">Tomorrow night. Same tables, same time. Come build something with people who are also building something.</p>' +
marquee(when) + marquee(when) +
'<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on. If you know someone who should be in that room, forward this to them.</p>' + '<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on.</p>' +
button("See what to expect", SITE); answerButtons();
var campaign = await tool("create_campaign", { var campaign = await tool("create_campaign", {
name: "Founder's Break reminder " + target, name: "Founder's Break reminder " + target,

View file

@ -8,7 +8,7 @@
// User code is compiled as `async function (ctx) { ... }`, so the sandbox // User code is compiled as `async function (ctx) { ... }`, so the sandbox
// capabilities arrive on ctx rather than as globals. // capabilities arrive on ctx rather than as globals.
var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log; var event = ctx.event, env = ctx.env, fetch = ctx.fetch, log = ctx.log, kv = ctx.kv;
var API = "https://api.corben.world/mcp"; var API = "https://api.corben.world/mcp";
@ -106,11 +106,16 @@ var email = String(data.email || "").trim();
var name = String(data.name || "").trim(); var name = String(data.name || "").trim();
var building = String(data.building || "").trim(); var building = String(data.building || "").trim();
var eventDate = String(data.event_date || "").trim(); var eventDate = String(data.event_date || "").trim();
var attending = String(data.attending || "yes").trim().toLowerCase();
var viaEmail = String(data.source || "") === "reminder-email";
// Honeypot: a real browser never fills this. // Honeypot: a real browser never fills this.
if (String(data.company_website || "").trim()) return { skipped: "honeypot" }; if (String(data.company_website || "").trim()) return { skipped: "honeypot" };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable email" }; if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable email" };
var seenKey = "founders-break:seen:" + email.toLowerCase();
var returning = !!(await kv.get(seenKey));
var first = name.split(/\s+/)[0] || "there"; var first = name.split(/\s+/)[0] || "there";
var last = name.split(/\s+/).slice(1).join(" "); var last = name.split(/\s+/).slice(1).join(" ");
var when = prettyDate(eventDate); var when = prettyDate(eventDate);
@ -179,6 +184,39 @@ if (contactId) {
} }
// 3. Confirmation to the person who RSVPed. // 3. Confirmation to the person who RSVPed.
//
// Three cases, because the same email for all of them would be wrong: a first
// timer needs the full invitation, somebody who already knows the drill needs
// one line, and somebody who just said no needs nothing at all.
var mailed = "none";
if (attending === "no") {
// Saying no gets no email. Confirming a non-attendance by mail is noise, and
// the page they clicked from already told them it landed.
mailed = "skipped (declined)";
} else if (returning) {
var backBody =
'<p style="margin:0;">You are down for this one, ' + esc(first) + '. Same place, same time.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">Bring whatever you are working on. If you know someone who should be in that room, forward this.</p>' +
button("See what to expect", SITE);
await tool("send_email", {
from: env.FROM,
to: email,
subject: "See you " + when,
html: shell({
headline: "You are down for " + when.split(",")[0] + ".",
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: backBody,
}),
text: "You are down for this one, " + first + ".\n\n" + when + ", 7:00 to 9:00 PM\n" +
VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" + SITE,
});
mailed = "returning";
} else {
var confirmBody = var confirmBody =
'<p style="margin:0;">Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.</p>' + '<p style="margin:0;">Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.</p>' +
marquee(when) + marquee(when) +
@ -197,11 +235,16 @@ await tool("send_email", {
imageAlt: "A blueprint drawing of a pool table and the break shot.", imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: confirmBody, body: confirmBody,
}), }),
text: "Nice one, " + first + ". Nothing else to do now, just turn up and find us at the tables.\n\n" + when + ", 7:00 to 9:00 PM\n" + text: "Nice one, " + first + ". Nothing else to do now, just turn up and find us at the tables.\n\n" +
VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" + when + ", 7:00 to 9:00 PM\n" + VENUE + "\n" + STREET + ", " + CITY +
"\nFree to get in. 18 and over, so bring an ID.\n\n" +
"You do not need to be good at pool. Bring whatever you are working on.\n" + "You do not need to be good at pool. Bring whatever you are working on.\n" +
"We will nudge you the day before.\n\n" + SITE, "We will nudge you the day before.\n\n" + SITE,
}); });
mailed = "first time";
}
await kv.set(seenKey, new Date().toISOString());
// 4. Internal heads-up. Plain on purpose, this one is for us. // 4. Internal heads-up. Plain on purpose, this one is for us.
var line = function (label, value) { var line = function (label, value) {
@ -214,20 +257,26 @@ var notifyBody =
line("Email", email) + line("Email", email) +
line("Building", building || "did not say") + line("Building", building || "did not say") +
line("Coming to", when) + line("Coming to", when) +
line("Answer", attending === "no" ? "NOT coming this week" : "coming") +
line("Came from", viaEmail ? "one-click in the reminder email" : "the site form") +
line("Mailing list", listed ? "added" : "not added, check the logs") + line("Mailing list", listed ? "added" : "not added, check the logs") +
'<p style="margin:18px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Their confirmation has already gone out.</p>'; '<p style="margin:18px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Confirmation sent: ' + mailed + '.</p>';
await tool("send_email", { await tool("send_email", {
from: env.FROM, from: env.FROM,
to: env.NOTIFY, to: env.NOTIFY,
subject: (name || email) + " is coming on " + when, subject: (name || email) + (attending === "no" ? " is OUT for " : " is coming on ") + when,
html: shell({ html: shell({
headline: esc(name || "Someone") + " RSVPed.", headline: esc(name || "Someone") + (attending === "no" ? " cannot make it." : " RSVPed."),
preview: (name || email) + " for " + when, preview: (name || email) + " for " + when,
body: notifyBody, body: notifyBody,
}), }),
text: (name || "(no name)") + " RSVPed for " + when + "\nEmail: " + email + text: (name || "(no name)") + (attending === "no" ? " is NOT coming " : " RSVPed for ") + when +
"\nBuilding: " + (building || "did not say") + "\nList: " + (listed ? "added" : "not added"), "\nEmail: " + email + "\nBuilding: " + (building || "did not say") +
"\nList: " + (listed ? "added" : "not added"),
}); });
return { ok: true, contact_id: contactId, listed: listed, event_date: eventDate }; return {
ok: true, contact_id: contactId, listed: listed, event_date: eventDate,
attending: attending, returning: returning, confirmation: mailed, via_email: viaEmail,
};

View file

@ -224,13 +224,61 @@
}); });
}); });
function showDone() { function showDone(kind) {
form.hidden = true; form.hidden = true;
done.hidden = false; done.hidden = false;
if (kind === "no") {
var h = done.querySelector("h3"), p = done.querySelector("p"), ics = done.querySelector(".ics");
h.textContent = "No worries.";
p.textContent = "We have taken you off this week. You will still get next week's reminder.";
if (ics) ics.hidden = true;
}
done.setAttribute("tabindex", "-1"); done.setAttribute("tabindex", "-1");
done.focus({ preventScroll: true }); done.focus({ preventScroll: true });
} }
/* No-JS fallback lands back here with ?rsvp=ok after the plain form POST. */ /* No-JS fallback lands back here with ?rsvp=ok after the plain form POST. */
if (new URLSearchParams(window.location.search).get("rsvp") === "ok") showDone(); 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.";
});
}
}
})(); })();