diff --git a/ops/founders-break-reminder.js b/ops/founders-break-reminder.js index 9425af3..ed7c9a9 100644 --- a/ops/founders-break-reminder.js +++ b/ops/founders-break-reminder.js @@ -118,6 +118,26 @@ function button(label, href) { ''; } + +// 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 '' + + '' + + '' + + '' + + '' + + '
' + + 'Yes, I am coming' + + '' + + 'Not this week' + + '
' + + '

One tap, nothing to fill in. You are already on the list either way.

'; +} + // ── main ──────────────────────────────────────────────────────────────── var force = !!(event && event.force); var now = etNow(); @@ -139,8 +159,8 @@ var when = pretty(target); var body = '

Tomorrow night. Same tables, same time. Come build something with people who are also building something.

' + marquee(when) + - '

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.

' + - button("See what to expect", SITE); + '

Still zero pool skills required. Bring a valid ID and whatever you are working on.

' + + answerButtons(); var campaign = await tool("create_campaign", { name: "Founder's Break reminder " + target, diff --git a/ops/founders-break-rsvp.js b/ops/founders-break-rsvp.js index a68e422..9054ab9 100644 --- a/ops/founders-break-rsvp.js +++ b/ops/founders-break-rsvp.js @@ -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,29 +184,67 @@ if (contactId) { } // 3. Confirmation to the person who RSVPed. -var confirmBody = - '

Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.

' + - marquee(when) + - '

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.

' + - '

We will nudge you the day before so it does not sneak up on you.

' + - button("See what to expect", SITE); +// +// 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"; -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, -}); +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 = + '

You are down for this one, ' + esc(first) + '. Same place, same time.

' + + marquee(when) + + '

Bring whatever you are working on. If you know someone who should be in that room, forward this.

' + + 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 = + '

Nice one, ' + esc(first) + '. Nothing else to do now. Just turn up and find us at the tables.

' + + marquee(when) + + '

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.

' + + '

We will nudge you the day before so it does not sneak up on you.

' + + 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. 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") + - '

Their confirmation has already gone out.

'; + '

Confirmation sent: ' + mailed + '.

'; 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, +}; diff --git a/public/js/main.js b/public/js/main.js index f452d46..7ee8437 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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=
. + 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."; + }); + } + } })();