Add /flyer: a shareable page for the poster, with native share and per-channel fallbacks

This commit is contained in:
Devin Willis 2026-08-26 16:02:48 -04:00
parent a3ea084bc1
commit f907210a6e
No known key found for this signature in database
GPG key ID: 3776B3C5AFF3FFEA
5 changed files with 335 additions and 3 deletions

View file

@ -571,3 +571,66 @@ form { margin-top: 2rem; }
.js .reveal, .js .reveal.in { opacity: 1; transform: none; transition: none; } .js .reveal, .js .reveal.in { opacity: 1; transform: none; transition: none; }
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::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; }
}

125
public/js/share.js Normal file
View file

@ -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. */
})();

View file

@ -62,6 +62,9 @@ module.exports = {
venue: venue:
"https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/458dab49-e942-4016-a744-127da6f9a754/silverq-venue.jpg", "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", 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: venueLogo:
"https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/5adbc5b1-ed83-48ae-b067-8ca01d0fde44/silverq-logo.png", "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/5adbc5b1-ed83-48ae-b067-8ca01d0fde44/silverq-logo.png",
}, },

139
views/flyer.ejs Normal file
View file

@ -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.`;
%><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Share <%= site.name %> | Printable flyer</title>
<meta name="description" content="<%= site.description %>">
<meta name="theme-color" content="#06060b">
<meta property="og:type" content="website">
<meta property="og:title" content="<%= site.name %> | Every <%= weekdayName %> at <%= V.name %>">
<meta property="og:description" content="<%= site.description %>">
<meta property="og:image" content="<%= site.images.share %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="<%= site.url %>/flyer">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<script>document.documentElement.className += " js";</script>
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="nav" id="nav">
<a class="brand" href="/">
<img class="brand-mark" src="/images/onetouch-studio.svg" alt="" width="26" height="26">
<span class="brand-name"><%= site.name %></span>
</a>
<nav class="nav-right" aria-label="Primary">
<a class="nav-link" href="/#details">Details</a>
<a class="nav-link" href="/#venue">The venue</a>
<a class="nav-cta" href="/#rsvp">RSVP</a>
</nav>
</header>
<main id="main" class="share-page">
<div class="share-grid">
<figure class="flyer-shot">
<a href="<%= pdf %>" download data-track="flyer-open-preview">
<img src="/images/flyer-preview.png" width="1632" height="2112"
alt="The printable flyer: The Founder's Break, every Thursday 7 to 9 PM at Silver Q, 225 W University Ave.">
</a>
</figure>
<div class="share-copy">
<p class="eyebrow">Printable flyer</p>
<h1>Put this up somewhere.</h1>
<p class="share-sub">One page, US Letter, prints fine in black and white. The code on it goes straight to the RSVP. The next Break is <%= nextLong %>.</p>
<div class="share-actions">
<a class="btn btn-primary" href="<%= pdf %>" download data-track="flyer-download">Download the PDF</a>
<button class="btn btn-ghost" type="button" id="share-native" hidden data-track="share-native">Share</button>
</div>
<div class="share-channels">
<p class="channels-label">Or send it directly</p>
<ul>
<li><a id="share-sms" href="#" data-track="share-sms">Text message</a></li>
<li><a id="share-email" href="#" data-track="share-email">Email</a></li>
<li><a id="share-whatsapp" href="#" target="_blank" rel="noopener" data-track="share-whatsapp">WhatsApp</a></li>
<li><a id="share-x" href="#" target="_blank" rel="noopener" data-track="share-x">X</a></li>
<li><a id="share-linkedin" href="#" target="_blank" rel="noopener" data-track="share-linkedin">LinkedIn</a></li>
<li><a id="share-facebook" href="#" target="_blank" rel="noopener" data-track="share-facebook">Facebook</a></li>
</ul>
<button class="copy-link" type="button" id="copy-link" data-track="share-copy">
<span id="copy-label">Copy the link</span>
<span class="copy-url"><%= site.url.replace(/^https:\/\//, "") %></span>
</button>
</div>
</div>
</div>
</main>
<footer class="foot">
<p class="foot-line">Bring your ideas and a valid ID.</p>
<div class="foot-meta">
<a class="foot-host" href="<%= site.host.url %>" target="_blank" rel="noopener">
<img src="/images/onetouch-studio.svg" alt="" width="20" height="20">
<span>Hosted by <%= site.host.name %></span>
</a>
<a href="/#rsvp">RSVP</a>
<a href="<%= V.mapUrl %>" target="_blank" rel="noopener"><%= V.name %>, <%= V.street %></a>
</div>
</footer>
<script>
window.__fbShare = {
url: <%- JSON.stringify(site.url) %>,
pdf: <%- JSON.stringify(site.url + pdf) %>,
text: <%- JSON.stringify(shareText) %>,
title: <%- JSON.stringify(site.name) %>,
siteId: <%- JSON.stringify(tenant.id) %>
};
</script>
<script src="/js/share.js" defer></script>
</body>
</html>

View file

@ -74,7 +74,9 @@
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="<%= site.name %>"> <meta property="og:title" content="<%= site.name %>">
<meta property="og:description" content="<%= site.description %>"> <meta property="og:description" content="<%= site.description %>">
<meta property="og:image" content="<%= site.images.hero %>"> <meta property="og:image" content="<%= site.images.share %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:url" content="<%= site.url %>"> <meta property="og:url" content="<%= site.url %>">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
@ -330,7 +332,7 @@
<h3>You are on the list.</h3> <h3>You are on the list.</h3>
<p><%= isTonight ? "Tonight" : nextLong %> at <%= V.name %>, <%= V.street %>, <%= site.startTime %>. A confirmation is on its way to your inbox.</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" data-track="add-to-calendar">Add the whole series to your calendar</a> <a class="ics" href="/assets/founders-break.ics" data-track="add-to-calendar">Add the whole series to your calendar</a>
<p class="done-extra">Bringing people? <a href="/assets/founders-break-flyer.pdf" download data-track="flyer-after-rsvp">Printable flyer (PDF)</a></p> <p class="done-extra">Bringing people? <a href="/flyer" data-track="flyer-after-rsvp">Grab the flyer and share it</a></p>
</div> </div>
</div> </div>
</section> </section>
@ -344,7 +346,7 @@
<img src="/images/onetouch-studio.svg" alt="" width="20" height="20"> <img src="/images/onetouch-studio.svg" alt="" width="20" height="20">
<span>Hosted by <%= site.host.name %></span> <span>Hosted by <%= site.host.name %></span>
</a> </a>
<a href="/assets/founders-break-flyer.pdf" download data-track="flyer-footer">Printable flyer (PDF)</a> <a href="/flyer" data-track="flyer-footer">Printable flyer</a>
<a href="mailto:<%= site.host.email %>"><%= site.host.email %></a> <a href="mailto:<%= site.host.email %>"><%= site.host.email %></a>
<a href="<%= V.mapUrl %>" target="_blank" rel="noopener" data-track="venue-map"><%= V.name %>, <%= V.street %></a> <a href="<%= V.mapUrl %>" target="_blank" rel="noopener" data-track="venue-map"><%= V.name %>, <%= V.street %></a>
</div> </div>