One-tap yes/no from the reminder, and a shorter email for returning regulars
This commit is contained in:
parent
ebfc15bd27
commit
1113032c6c
3 changed files with 150 additions and 33 deletions
|
|
@ -118,6 +118,26 @@ function button(label, href) {
|
|||
'</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 ────────────────────────────────────────────────────────────────
|
||||
var force = !!(event && event.force);
|
||||
var now = etNow();
|
||||
|
|
@ -139,8 +159,8 @@ var when = pretty(target);
|
|||
var body =
|
||||
'<p style="margin:0;">Tomorrow night. Same tables, same time. Come build something with people who are also building something.</p>' +
|
||||
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>' +
|
||||
button("See what to expect", SITE);
|
||||
'<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on.</p>' +
|
||||
answerButtons();
|
||||
|
||||
var campaign = await tool("create_campaign", {
|
||||
name: "Founder's Break reminder " + target,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
// User code is compiled as `async function (ctx) { ... }`, so the sandbox
|
||||
// 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";
|
||||
|
||||
|
|
@ -106,11 +106,16 @@ var email = String(data.email || "").trim();
|
|||
var name = String(data.name || "").trim();
|
||||
var building = String(data.building || "").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.
|
||||
if (String(data.company_website || "").trim()) return { skipped: "honeypot" };
|
||||
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 last = name.split(/\s+/).slice(1).join(" ");
|
||||
var when = prettyDate(eventDate);
|
||||
|
|
@ -179,6 +184,39 @@ if (contactId) {
|
|||
}
|
||||
|
||||
// 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 =
|
||||
'<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) +
|
||||
|
|
@ -197,11 +235,16 @@ await tool("send_email", {
|
|||
imageAlt: "A blueprint drawing of a pool table and the break shot.",
|
||||
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" +
|
||||
VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" +
|
||||
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" + 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" +
|
||||
"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.
|
||||
var line = function (label, value) {
|
||||
|
|
@ -214,20 +257,26 @@ var notifyBody =
|
|||
line("Email", email) +
|
||||
line("Building", building || "did not say") +
|
||||
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") +
|
||||
'<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", {
|
||||
from: env.FROM,
|
||||
to: env.NOTIFY,
|
||||
subject: (name || email) + " is coming on " + when,
|
||||
subject: (name || email) + (attending === "no" ? " is OUT for " : " is coming on ") + when,
|
||||
html: shell({
|
||||
headline: esc(name || "Someone") + " RSVPed.",
|
||||
headline: esc(name || "Someone") + (attending === "no" ? " cannot make it." : " RSVPed."),
|
||||
preview: (name || email) + " for " + when,
|
||||
body: notifyBody,
|
||||
}),
|
||||
text: (name || "(no name)") + " RSVPed for " + when + "\nEmail: " + email +
|
||||
"\nBuilding: " + (building || "did not say") + "\nList: " + (listed ? "added" : "not added"),
|
||||
text: (name || "(no name)") + (attending === "no" ? " is NOT coming " : " RSVPed for ") + when +
|
||||
"\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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -224,13 +224,61 @@
|
|||
});
|
||||
});
|
||||
|
||||
function showDone() {
|
||||
function showDone(kind) {
|
||||
form.hidden = true;
|
||||
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.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();
|
||||
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.";
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue