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>';
|
'</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,
|
||||||
|
|
|
||||||
|
|
@ -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,29 +184,67 @@ if (contactId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Confirmation to the person who RSVPed.
|
// 3. Confirmation to the person who RSVPed.
|
||||||
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>' +
|
// Three cases, because the same email for all of them would be wrong: a first
|
||||||
marquee(when) +
|
// timer needs the full invitation, somebody who already knows the drill needs
|
||||||
'<p style="margin:20px 0 0;">You do not need to be good at pool. Most of us are not. Bring whatever you are working on and we will point you at the people worth meeting.</p>' +
|
// one line, and somebody who just said no needs nothing at all.
|
||||||
'<p style="margin:14px 0 0;">We will nudge you the day before so it does not sneak up on you.</p>' +
|
var mailed = "none";
|
||||||
button("See what to expect", SITE);
|
|
||||||
|
|
||||||
await tool("send_email", {
|
if (attending === "no") {
|
||||||
from: env.FROM,
|
// Saying no gets no email. Confirming a non-attendance by mail is noise, and
|
||||||
to: email,
|
// the page they clicked from already told them it landed.
|
||||||
subject: "You are in: The Founder's Break, " + when,
|
mailed = "skipped (declined)";
|
||||||
html: shell({
|
} else if (returning) {
|
||||||
headline: "You are in.",
|
var backBody =
|
||||||
preview: when + ", 7:00 PM at Silver Q. Free, 18+.",
|
'<p style="margin:0;">You are down for this one, ' + esc(first) + '. Same place, same time.</p>' +
|
||||||
image: HERO,
|
marquee(when) +
|
||||||
imageAlt: "A blueprint drawing of a pool table and the break shot.",
|
'<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>' +
|
||||||
body: confirmBody,
|
button("See what to expect", SITE);
|
||||||
}),
|
|
||||||
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" +
|
await tool("send_email", {
|
||||||
VENUE + "\n" + STREET + ", " + CITY + "\nFree to get in. 18 and over, so bring an ID.\n\n" +
|
from: env.FROM,
|
||||||
"You do not need to be good at pool. Bring whatever you are working on.\n" +
|
to: email,
|
||||||
"We will nudge you the day before.\n\n" + SITE,
|
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) +
|
||||||
|
'<p style="margin:20px 0 0;">You do not need to be good at pool. Most of us are not. Bring whatever you are working on and we will point you at the people worth meeting.</p>' +
|
||||||
|
'<p style="margin:14px 0 0;">We will nudge you the day before so it does not sneak up on you.</p>' +
|
||||||
|
button("See what to expect", SITE);
|
||||||
|
|
||||||
|
await tool("send_email", {
|
||||||
|
from: env.FROM,
|
||||||
|
to: email,
|
||||||
|
subject: "You are in: The Founder's Break, " + when,
|
||||||
|
html: shell({
|
||||||
|
headline: "You are in.",
|
||||||
|
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: 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" +
|
||||||
|
"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.
|
// 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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue