Fix the post-RSVP layout, calm the copy, make the latest answer win, add unsubscribe

This commit is contained in:
Devin Willis 2026-08-26 15:11:01 -04:00
parent 1113032c6c
commit d54d872090
No known key found for this signature in database
GPG key ID: 3776B3C5AFF3FFEA
4 changed files with 65 additions and 32 deletions

View file

@ -65,6 +65,7 @@ function esc(s) {
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png";
var UNSUB = "https://api.corben.world/campaigns/unsubscribe?user_id=c23ea202-5442-41b4-9a1b-1586e81c50eb&email=";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
@ -93,8 +94,10 @@ function shell(opts) {
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>. ' +
'Reply and we will take you off the list.' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>.' +
'</p><p style="margin:12px 0 0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;">' +
'<a href="' + UNSUB + (opts.unsubscribeFor || '') + '" style="color:#8b89a6;text-decoration:underline;">Unsubscribe from these emails</a>' +
' and we will not email you about the Break again.' +
'</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
@ -108,7 +111,7 @@ function marquee(when) {
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free to get in. 18 and over, so bring an ID.</p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free entry. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
@ -135,7 +138,7 @@ function answerButtons() {
'<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>';
'<p style="margin:12px 0 0;font:400 13px/1.5 ' + SANS + ';color:#6f6d88;">One tap, nothing to fill in. Either answer keeps you on the list for future weeks.</p>';
}
// ── main ────────────────────────────────────────────────────────────────
@ -157,9 +160,9 @@ if (!force && (await kv.get(marker))) return { skipped: "already sent for " + ta
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>' +
'<p style="margin:0;">The Break is tomorrow night. Same place, same time.</p>' +
marquee(when) +
'<p style="margin:20px 0 0;">Still zero pool skills required. Bring a valid ID and whatever you are working on.</p>' +
'<p style="margin:20px 0 0;">Bring a valid ID and whatever you are working on.</p>' +
answerButtons();
var campaign = await tool("create_campaign", {
@ -174,9 +177,10 @@ var campaign = await tool("create_campaign", {
image: HERO,
imageAlt: "A blueprint drawing of a pool table and the break shot.",
body: body,
unsubscribeFor: '{{email}}',
}),
text_body: "The Break is tomorrow, " + when + ".\n\n" + VENUE + ", " + STREET + ", " + CITY +
"\n7:00 to 9:00 PM. Free, 18+.\n\nZero pool skills required. Bring a valid ID.\n\n" + SITE,
"\n7:00 to 9:00 PM. Free entry, 18 and over.\n\nBring a valid ID.\n\n" + SITE,
tags: ["founders-break", "reminder"],
});

View file

@ -44,6 +44,7 @@ function prettyDate(iso) {
}
var HERO = "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/e7119448-83ba-4da4-95d8-b64045c21160/email-header-trim.png";
var UNSUB = "https://api.corben.world/campaigns/unsubscribe?user_id=c23ea202-5442-41b4-9a1b-1586e81c50eb&email=";
var SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
// One shell for every outbound email so they all look like the site. `image`
@ -72,8 +73,10 @@ function shell(opts) {
'<tr><td style="padding:0 34px 30px;">' +
'<p style="margin:0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;border-top:1px solid rgba(255,255,255,.08);padding-top:18px;">' +
'Hosted by <a href="https://onetouch.studio" style="color:#34d399;text-decoration:none;">OneTouch Studio</a>. ' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>. ' +
'Reply and we will take you off the list.' +
'You are getting this because you RSVPed at <a href="' + SITE + '" style="color:#34d399;text-decoration:none;">foundersbreak.onetouch.studio</a>.' +
'</p><p style="margin:12px 0 0;font:400 12px/1.6 ' + SANS + ';color:#6f6d88;">' +
'<a href="' + UNSUB + (opts.unsubscribeFor || '') + '" style="color:#8b89a6;text-decoration:underline;">Unsubscribe from these emails</a>' +
' and we will not email you about the Break again.' +
'</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
@ -87,7 +90,7 @@ function marquee(when) {
'<p style="margin:14px 0 0;font:400 15px/1.55 ' + SANS + ';color:#c2c0d4;">' +
'<a href="' + MAP + '" style="color:#c2c0d4;text-decoration:none;border-bottom:1px solid rgba(16,185,129,.45);">' +
VENUE + '<br>' + STREET + ', ' + CITY + '</a></p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free to get in. 18 and over, so bring an ID.</p>' +
'<p style="margin:12px 0 0;font:400 14px/1.5 ' + SANS + ';color:#8b89a6;">Free entry. 18 and over, so bring an ID.</p>' +
'</td></tr></table>';
}
@ -116,6 +119,13 @@ if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return { skipped: "no usable e
var seenKey = "founders-break:seen:" + email.toLowerCase();
var returning = !!(await kv.get(seenKey));
// One answer per person per event date. Clicking Yes then No must land on No,
// and clicking the same answer twice must not send a second email.
var answerKey = "founders-break:answer:" + (eventDate || "next") + ":" + email.toLowerCase();
var previous = await kv.get(answerKey);
var changed = previous !== attending;
await kv.set(answerKey, attending);
var first = name.split(/\s+/)[0] || "there";
var last = name.split(/\s+/).slice(1).join(" ");
var when = prettyDate(eventDate);
@ -194,11 +204,14 @@ 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 (!changed) {
// Same answer as last time, most likely a second click on the same link.
mailed = "skipped (already confirmed)";
} else if (returning) {
var backBody =
'<p style="margin:0;">You are down for this one, ' + esc(first) + '. Same place, same time.</p>' +
'<p style="margin:0;">You are confirmed for this week, ' + 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>' +
'<p style="margin:20px 0 0;">Bring whatever you are working on. Forward this to anyone who should be there.</p>' +
button("See what to expect", SITE);
await tool("send_email", {
@ -206,40 +219,42 @@ if (attending === "no") {
to: email,
subject: "See you " + when,
html: shell({
headline: "You are down for " + when.split(",")[0] + ".",
headline: "Confirmed 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,
unsubscribeFor: encodeURIComponent(email),
}),
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,
text: "You are confirmed for this week, " + first + ".\n\n" + when + ", 7:00 to 9:00 PM\n" +
VENUE + "\n" + STREET + ", " + CITY + "\nFree entry. 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>' +
'<p style="margin:0;">You are on the list, ' + esc(first) + '. There is nothing else to do before then.</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>' +
'<p style="margin:20px 0 0;">Pool experience is not expected. Bring whatever you are working on and we will introduce you to the people worth meeting.</p>' +
'<p style="margin:14px 0 0;">A reminder will follow the day before.</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,
subject: "You are on the list: The Founder's Break, " + when,
html: shell({
headline: "You are in.",
headline: "You are on the list.",
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,
unsubscribeFor: encodeURIComponent(email),
}),
text: "Nice one, " + first + ". Nothing else to do now, just turn up and find us at the tables.\n\n" +
text: "You are on the list, " + first + ". There is nothing else to do before then.\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,
"\nFree entry. 18 and over, so bring an ID.\n\n" +
"Pool experience is not expected. Bring whatever you are working on.\n" +
"A reminder will follow the day before.\n\n" + SITE,
});
mailed = "first time";
}
@ -257,7 +272,8 @@ 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("Answer", (attending === "no" ? "NOT coming this week" : "coming") +
(previous && changed ? " (changed from " + esc(previous) + ")" : "")) +
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;">Confirmation sent: ' + mailed + '.</p>';
@ -270,6 +286,7 @@ await tool("send_email", {
headline: esc(name || "Someone") + (attending === "no" ? " cannot make it." : " RSVPed."),
preview: (name || email) + " for " + when,
body: notifyBody,
unsubscribeFor: encodeURIComponent(env.NOTIFY),
}),
text: (name || "(no name)") + (attending === "no" ? " is NOT coming " : " RSVPed for ") + when +
"\nEmail: " + email + "\nBuilding: " + (building || "did not say") +
@ -278,5 +295,6 @@ await tool("send_email", {
return {
ok: true, contact_id: contactId, listed: listed, event_date: eventDate,
attending: attending, returning: returning, confirmation: mailed, via_email: viaEmail,
attending: attending, previous: previous || null, changed: changed,
returning: returning, confirmation: mailed, via_email: viaEmail,
};

View file

@ -155,6 +155,7 @@
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");
@ -225,13 +226,18 @@
});
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") {
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.";
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 });

View file

@ -276,6 +276,10 @@
<!-- RSVP -->
<section class="rsvp" id="rsvp">
<div class="rsvp-panel reveal">
<!-- Everything the visitor interacts with lives in one wrapper so the
confirmation can replace the whole block. Hiding only the form left
the heading and the QR stranded above the confirmation. -->
<div id="rsvp-live">
<h2>RSVP for <%= isTonight ? "tonight" : nextLong %></h2>
<p class="rsvp-sub">Not required, but it helps us know how many tables to hold. We will email you a reminder the day before.</p>
@ -316,14 +320,15 @@
alt="QR code linking to the RSVP form on this page.">
</a>
<div class="qr-copy">
<p class="qr-title">Point a phone at this.</p>
<p>It opens straight to the RSVP. Screenshot it and send it to whoever should be there.</p>
<p class="qr-title">Scan to RSVP</p>
<p>Opens this page at the form. Screenshot it to share.</p>
</div>
</div>
</div>
<div class="rsvp-done" id="rsvp-done" hidden>
<h3>You are on the list.</h3>
<p>Check your inbox for a confirmation. See you <%= isTonight ? "tonight" : nextLong %> at <%= V.name %>, <%= V.street %>, <%= site.startTime %>. Bring your ideas and a valid ID.</p>
<p><%= isTonight ? "Tonight" : nextLong %> at <%= V.name %>, <%= V.street %>, <%= site.startTime %>. A confirmation is on its way to your inbox.</p>
<a class="ics" href="/assets/founders-break.ics">Add the whole series to your calendar</a>
</div>
</div>