From f907210a6ea348d64fdfb72e1b91659b1d65b4f8 Mon Sep 17 00:00:00 2001 From: Devin Willis Date: Wed, 26 Aug 2026 16:02:48 -0400 Subject: [PATCH] Add /flyer: a shareable page for the poster, with native share and per-channel fallbacks --- public/css/style.css | 63 ++++++++++++++++++++ public/js/share.js | 125 ++++++++++++++++++++++++++++++++++++++ site.config.js | 3 + views/flyer.ejs | 139 +++++++++++++++++++++++++++++++++++++++++++ views/index.ejs | 8 ++- 5 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 public/js/share.js create mode 100644 views/flyer.ejs diff --git a/public/css/style.css b/public/css/style.css index 5e2c579..2e1341c 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -571,3 +571,66 @@ form { margin-top: 2rem; } .js .reveal, .js .reveal.in { opacity: 1; transform: none; transition: none; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } + +/* ── /flyer share page ────────────────────────────────────── */ + +.share-page { max-width: var(--shell); margin: 0 auto; padding: calc(64px + 3.5rem) var(--gut) 5rem; } + +.share-grid { + display: grid; grid-template-columns: 0.82fr 1.18fr; + gap: 3.4rem; align-items: center; +} + +/* The flyer is the subject of this page, so it gets shown as a physical sheet + rather than a thumbnail: white paper, a real shadow, slightly tilted. */ +.flyer-shot a { + display: block; border-radius: 4px; overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08); + transform: rotate(-1.4deg); + transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease); +} +.flyer-shot a:hover { transform: rotate(0deg) translateY(-4px); box-shadow: 0 30px 70px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--border-hover); } +.flyer-shot img { width: 100%; height: auto; display: block; } + +.share-copy h1 { font-size: clamp(2.1rem, 4.4vw, 3.1rem); font-weight: 700; } +.share-sub { margin-top: 1rem; max-width: 44ch; color: var(--muted); font-size: 1.04rem; } +.share-actions { display: flex; flex-wrap: wrap; gap: 0.8rem; margin-top: 2rem; } + +.share-channels { margin-top: 2.4rem; padding-top: 1.6rem; border-top: 1px solid var(--hairline); } +.channels-label { + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--muted); margin-bottom: 1rem; +} +.share-channels ul { list-style: none; display: flex; flex-wrap: wrap; gap: 0.55rem; } +.share-channels li a { + display: inline-block; padding: 0.5rem 1rem; + border: 1px solid var(--hairline); border-radius: var(--r-control); + color: var(--text); text-decoration: none; font-size: 0.88rem; font-weight: 500; + transition: border-color 0.2s var(--ease), color 0.2s var(--ease), transform 0.2s var(--ease); +} +.share-channels li a:hover { border-color: var(--border-hover); color: var(--accent2); transform: translateY(-2px); } + +.copy-link { + margin-top: 1.2rem; width: 100%; text-align: left; cursor: pointer; + display: flex; align-items: baseline; gap: 0.7rem; flex-wrap: wrap; + padding: 0.8rem 1rem; font-family: inherit; + background: var(--surface); color: var(--text); + border: 1px dashed var(--hairline); border-radius: var(--r-control); + transition: border-color 0.2s var(--ease), background 0.2s var(--ease); +} +.copy-link:hover { border-color: var(--border-hover); background: var(--surface2); } +.copy-link.is-copied { border-style: solid; border-color: var(--accent); } +#copy-label { font-weight: 600; font-size: 0.88rem; } +.copy-url { color: var(--muted); font-size: 0.84rem; } + +@media (max-width: 1000px) { + .share-grid { grid-template-columns: 1fr; gap: 2.4rem; } + .flyer-shot { max-width: 22rem; margin: 0 auto; } +} + +@media (max-width: 760px) { + .share-page { padding: calc(64px + 2.5rem) var(--gut) 3.5rem; } + .share-channels ul { gap: 0.45rem; } + .share-channels li a { padding: 0.6rem 0.9rem; } + .share-actions .btn { flex: 1 1 auto; } +} diff --git a/public/js/share.js b/public/js/share.js new file mode 100644 index 0000000..052b6e3 --- /dev/null +++ b/public/js/share.js @@ -0,0 +1,125 @@ +/* Sharing for /flyer. + There is no "share protocol" as such. What exists is the Web Share API, which + on a phone opens the real OS share sheet (Messages, Mail, WhatsApp, Instagram, + AirDrop) and is the only thing that reaches apps a web page cannot link to. + Desktop browsers mostly do not have it, so every channel also has a plain URL + fallback, and copy-to-clipboard underneath that. */ + +(function () { + "use strict"; + + var S = window.__fbShare; + if (!S) return; + + function track(type, data) { + if (!S.siteId) return; + try { + fetch("https://api.corben.world/analytics/events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + site_id: S.siteId, + events: [{ event_type: type, page_path: location.pathname, event_data: data || {}, ts: Date.now() }], + }), + keepalive: true, + }).catch(function () {}); + } catch (e) {} + } + + var msg = S.text + " " + S.url; + var enc = encodeURIComponent; + + /* ── Plain links, so every one of these works with JS half-broken ── */ + var links = { + // "&body=" after "?&" is the form that works on both iOS and Android. + "share-sms": "sms:?&body=" + enc(msg), + "share-email": "mailto:?subject=" + enc(S.title) + "&body=" + enc(S.text + "\n\n" + S.url + "\n\nPrintable flyer: " + S.pdf), + "share-whatsapp": "https://wa.me/?text=" + enc(msg), + "share-x": "https://twitter.com/intent/tweet?text=" + enc(S.text) + "&url=" + enc(S.url), + "share-linkedin": "https://www.linkedin.com/sharing/share-offsite/?url=" + enc(S.url), + "share-facebook": "https://www.facebook.com/sharer/sharer.php?u=" + enc(S.url), + }; + Object.keys(links).forEach(function (id) { + var el = document.getElementById(id); + if (el) el.href = links[id]; + }); + + /* ── The OS share sheet, where it exists ── */ + var nativeBtn = document.getElementById("share-native"); + if (nativeBtn && navigator.share) { + nativeBtn.hidden = false; + nativeBtn.addEventListener("click", function () { + shareNative(); + }); + } + + function shareNative() { + var payload = { title: S.title, text: S.text, url: S.url }; + + // Sharing the PDF itself is much better than a link when it is going into a + // group chat, but canShare({files}) is the only honest way to ask, and it + // needs the file fetched first. Fall back to the link if anything about + // that fails rather than leaving the button dead. + if (navigator.canShare) { + fetch(S.pdf) + .then(function (r) { if (!r.ok) throw new Error("pdf " + r.status); return r.blob(); }) + .then(function (blob) { + var file = new File([blob], "founders-break-flyer.pdf", { type: "application/pdf" }); + if (!navigator.canShare({ files: [file] })) throw new Error("files unsupported"); + return navigator.share({ title: S.title, text: S.text, files: [file] }) + .then(function () { track("share", { channel: "native-file" }); }); + }) + .catch(function () { + navigator.share(payload) + .then(function () { track("share", { channel: "native-link" }); }) + .catch(function () {}); + }); + return; + } + + navigator.share(payload) + .then(function () { track("share", { channel: "native-link" }); }) + .catch(function () {}); + } + + /* ── Copy the link ── */ + var copyBtn = document.getElementById("copy-link"); + var copyLabel = document.getElementById("copy-label"); + if (copyBtn) { + copyBtn.addEventListener("click", function () { + var done = function (ok) { + copyLabel.textContent = ok ? "Copied" : "Press Cmd C to copy"; + copyBtn.classList.toggle("is-copied", ok); + track("share", { channel: "copy", ok: ok }); + setTimeout(function () { + copyLabel.textContent = "Copy the link"; + copyBtn.classList.remove("is-copied"); + }, 2200); + }; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(S.url).then(function () { done(true); }, function () { selectFallback(done); }); + } else { + selectFallback(done); + } + }); + } + + // Clipboard API is blocked in some in-app browsers. Selecting the text at + // least lets someone copy it by hand instead of hitting a dead button. + function selectFallback(done) { + var t = document.createElement("textarea"); + t.value = S.url; + t.setAttribute("readonly", ""); + t.style.cssText = "position:fixed;top:0;left:0;opacity:0"; + document.body.appendChild(t); + t.select(); + var ok = false; + try { ok = document.execCommand("copy"); } catch (e) {} + document.body.removeChild(t); + done(ok); + } + + /* Channel clicks are already caught by the site tracker via data-track, so + nothing extra is needed for those. */ +})(); diff --git a/site.config.js b/site.config.js index 88e739a..9ca890a 100644 --- a/site.config.js +++ b/site.config.js @@ -62,6 +62,9 @@ module.exports = { venue: "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/458dab49-e942-4016-a744-127da6f9a754/silverq-venue.jpg", qr: "/images/rsvp-qr.png", + // 1200x630 unfurl card, used wherever a link gets pasted. + share: + "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/376da0a1-6caf-42a5-970c-7a772ba79dac/og-share.jpg", venueLogo: "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/5adbc5b1-ed83-48ae-b067-8ca01d0fde44/silverq-logo.png", }, diff --git a/views/flyer.ejs b/views/flyer.ejs new file mode 100644 index 0000000..9d292dc --- /dev/null +++ b/views/flyer.ejs @@ -0,0 +1,139 @@ +<% + /* /flyer - the link you send someone so they can grab the poster and pass it + on. Same next-occurrence rule as the home page so the date on this page is + never stale. */ + const TZ = site.timezone || "America/New_York"; + const V = site.venue; + const now = new Date(); + + const todayISO = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", + }).format(now); + const hourET = Number( + new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "2-digit", hourCycle: "h23" }).format(now) + ) % 24; + + const toDate = (iso) => { const [y, m, d] = iso.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d)); }; + const isoOf = (dt) => dt.toISOString().slice(0, 10); + + let next = toDate(todayISO); + const first = toDate(site.firstEvent); + if (next < first) next = first; + while (next.getUTCDay() !== site.weekday) next.setUTCDate(next.getUTCDate() + 1); + if (isoOf(next) === todayISO && hourET >= (site.endHour24 || 21)) next.setUTCDate(next.getUTCDate() + 7); + (site.cancelled || []).length && (function () { + while ((site.cancelled || []).includes(isoOf(next))) next.setUTCDate(next.getUTCDate() + 7); + })(); + + const nextLong = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", weekday: "long", month: "long", day: "numeric", + }).format(next); + const weekdayName = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "long" }).format(next); + + const pdf = "/assets/founders-break-flyer.pdf"; + const shareText = `${site.name}: a free pool social for people building things in ${V.city}. Every ${weekdayName}, ${site.startTime} to ${site.endTime} at ${V.name}, ${V.street}. 18+, no cover.`; +%> + + + + +Share <%= site.name %> | Printable flyer + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/views/index.ejs b/views/index.ejs index 348490f..381cdbf 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -74,7 +74,9 @@ - + + + @@ -330,7 +332,7 @@

You are on the list.

<%= isTonight ? "Tonight" : nextLong %> at <%= V.name %>, <%= V.street %>, <%= site.startTime %>. A confirmation is on its way to your inbox.

Add the whole series to your calendar -

Bringing people? Printable flyer (PDF)

+

Bringing people? Grab the flyer and share it

@@ -344,7 +346,7 @@ Hosted by <%= site.host.name %> - Printable flyer (PDF) + Printable flyer <%= site.host.email %> <%= V.name %>, <%= V.street %>