diff --git a/ops/founders-break-reminder.js b/ops/founders-break-reminder.js index ed7c9a9..608fb68 100644 --- a/ops/founders-break-reminder.js +++ b/ops/founders-break-reminder.js @@ -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) { '' + '

' + 'Hosted by OneTouch Studio. ' + - 'You are getting this because you RSVPed at foundersbreak.onetouch.studio. ' + - 'Reply and we will take you off the list.' + + 'You are getting this because you RSVPed at foundersbreak.onetouch.studio.' + + '

' + + 'Unsubscribe from these emails' + + ' and we will not email you about the Break again.' + '

' + ''; } @@ -108,7 +111,7 @@ function marquee(when) { '

' + '' + VENUE + '
' + STREET + ', ' + CITY + '

' + - '

Free to get in. 18 and over, so bring an ID.

' + + '

Free entry. 18 and over, so bring an ID.

' + ''; } @@ -135,7 +138,7 @@ function answerButtons() { 'Not this week' + '' + '' + - '

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

'; + '

One tap, nothing to fill in. Either answer keeps you on the list for future weeks.

'; } // ── main ──────────────────────────────────────────────────────────────── @@ -157,9 +160,9 @@ if (!force && (await kv.get(marker))) return { skipped: "already sent for " + ta var when = pretty(target); var body = - '

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

' + + '

The Break is tomorrow night. Same place, same time.

' + marquee(when) + - '

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

' + + '

Bring a valid ID and whatever you are working on.

' + 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"], }); diff --git a/ops/founders-break-rsvp.js b/ops/founders-break-rsvp.js index 9054ab9..02e74ae 100644 --- a/ops/founders-break-rsvp.js +++ b/ops/founders-break-rsvp.js @@ -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) { '' + '

' + 'Hosted by OneTouch Studio. ' + - 'You are getting this because you RSVPed at foundersbreak.onetouch.studio. ' + - 'Reply and we will take you off the list.' + + 'You are getting this because you RSVPed at foundersbreak.onetouch.studio.' + + '

' + + 'Unsubscribe from these emails' + + ' and we will not email you about the Break again.' + '

' + ''; } @@ -87,7 +90,7 @@ function marquee(when) { '

' + '' + VENUE + '
' + STREET + ', ' + CITY + '

' + - '

Free to get in. 18 and over, so bring an ID.

' + + '

Free entry. 18 and over, so bring an ID.

' + ''; } @@ -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 = - '

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

' + + '

You are confirmed for this week, ' + 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.

' + + '

Bring whatever you are working on. Forward this to anyone who should be there.

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

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

' + + '

You are on the list, ' + esc(first) + '. There is nothing else to do before then.

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

' + + '

Pool experience is not expected. Bring whatever you are working on and we will introduce you to the people worth meeting.

' + + '

A reminder will follow the day before.

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

Confirmation sent: ' + mailed + '.

'; @@ -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, }; diff --git a/public/js/main.js b/public/js/main.js index 7ee8437..6e9106e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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 }); diff --git a/views/index.ejs b/views/index.ejs index ceede7c..56cfbc3 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -276,6 +276,10 @@
+ +

RSVP for <%= isTonight ? "tonight" : nextLong %>

Not required, but it helps us know how many tables to hold. We will email you a reminder the day before.

@@ -316,14 +320,15 @@ alt="QR code linking to the RSVP form on this page.">
-

Point a phone at this.

-

It opens straight to the RSVP. Screenshot it and send it to whoever should be there.

+

Scan to RSVP

+

Opens this page at the form. Screenshot it to share.

+