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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Skip to content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Printable flyer
+
Put this up somewhere.
+
One page, US Letter, prints fine in black and white. The code on it goes straight to the RSVP. The next Break is <%= nextLong %>.
+
+
+
+
+
Or send it directly
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
-
+
@@ -344,7 +346,7 @@
Hosted by <%= site.host.name %>
- Printable flyer (PDF)
+ Printable flyer
<%= site.host.email %>
<%= V.name %>, <%= V.street %>