Katie Malone
Members Public

New blood

Could artificial blood alleviate the need to donate?

Katie Malone
Members Public

Do not despair

For we have walked this path before.

Katie Malone
Members Public

A dire controversy

When science and sensationalism collide.

Katie Malone
Members Public

AI for the defense?

Can technology recreate the dead to speak on their own behalf?

/* * OnlySky UTM Capture Shim — Sprint 1 / D4 * * Paste into Ghost Admin → Settings → Code Injection → Site Footer (NOT Header). * Append after the existing gtag signup-event block; this script is independent * and stateless across page loads except for localStorage. * * What it does: * 1. On every page load: parse UTM params from window.location.search. If any * utm_* present AND no first-touch UTM already in localStorage, write the * five UTM params + landing slug + capture timestamp into localStorage * with a 90-day TTL. First-touch wins. * * 2. On signup confirmation pages (/free-subs/, /paid-sub/, /premium-sub/, * /hero-sub/): if localStorage has first-touch UTM and we haven't yet * POSTed for this conversion, send the capture to the OnlySky webhook * via navigator.sendBeacon() with a fetch() fallback. * * 3. Captures member identity if Ghost's portal exposes it (email and uuid). * If no member yet (e.g. anonymous landing on signup page), POSTs the * capture without identity — the server-side join uses timestamp * proximity as a last-resort match. * * Privacy: localStorage stays per-device. No third-party cookies. No personal * data leaves the device except what the user has voluntarily handed Ghost * (their email at signup). * * Tunable: edit WEBHOOK_URL below to match the deployed tunnel hostname. */ (function () { 'use strict'; // ─── config ────────────────────────────────────────────────────────── var WEBHOOK_URL = 'https://utm.onlys.ky/utm-capture'; // ← deployment target var SIGNUP_PAGES = ['/free-subs/', '/paid-sub/', '/premium-sub/', '/hero-sub/']; var TTL_DAYS = 90; var LS_PREFIX = 'onlysky_utm_'; // single namespace; cheap to grep var UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; // ─── helpers ───────────────────────────────────────────────────────── function lsGet(k) { try { return localStorage.getItem(LS_PREFIX + k); } catch (e) { return null; } } function lsSet(k, v) { try { localStorage.setItem(LS_PREFIX + k, v); } catch (e) { /* quota or private mode */ } } function lsRemove(k) { try { localStorage.removeItem(LS_PREFIX + k); } catch (e) {} } function isStale() { var captured = lsGet('captured_at'); if (!captured) return false; try { var ageMs = Date.now() - new Date(captured).getTime(); return ageMs > TTL_DAYS * 86400 * 1000; } catch (e) { return false; } } function dropAll() { UTM_KEYS.forEach(function (k) { lsRemove(k); }); lsRemove('captured_at'); lsRemove('first_landing_slug'); lsRemove('posted_for_member'); } function nowISO() { return new Date().toISOString(); } function landingSlugFromPath() { var p = window.location.pathname.replace(/^\/|\/$/g, ''); if (!p) return null; var seg = p.split('/')[0]; var reserved = ['tag', 'tags', 'author', 'authors', 'page', 'pages', 'members', 'portal', 'ghost', 'free-subs', 'paid-sub', 'premium-sub', 'hero-sub']; return (reserved.indexOf(seg) >= 0) ? null : seg; } // ─── step 1: first-touch capture on every page load ────────────────── if (isStale()) dropAll(); // expire after TTL var params = new URLSearchParams(window.location.search); var anyUtm = false; UTM_KEYS.forEach(function (k) { if (params.has(k)) anyUtm = true; }); if (anyUtm && !lsGet('captured_at')) { UTM_KEYS.forEach(function (k) { var v = params.get(k); if (v) lsSet(k, v); }); lsSet('captured_at', nowISO()); var slug = landingSlugFromPath(); if (slug) lsSet('first_landing_slug', slug); } // ─── step 2: POST on signup-confirmation pages ────────────────────── if (SIGNUP_PAGES.indexOf(window.location.pathname) === -1) return; if (!lsGet('captured_at')) return; // no first-touch on this device → nothing to send // Try to get member identity from Ghost Portal. function getMember() { try { if (window.MembersJS && typeof window.MembersJS.getMember === 'function') { return window.MembersJS.getMember(); } if (window.ghost && window.ghost.member) return window.ghost.member; } catch (e) {} return null; } function buildPayload(member) { var p = { captured_at: lsGet('captured_at'), conversion_at: nowISO(), conversion_page: window.location.pathname, first_landing_slug: lsGet('first_landing_slug'), member_email: member && member.email ? member.email : null, member_uuid: member && member.uuid ? member.uuid : null }; UTM_KEYS.forEach(function (k) { p[k] = lsGet(k); }); return p; } function postCapture(payload) { var posted_key = (payload.member_email || payload.member_uuid || payload.conversion_at); if (lsGet('posted_for_member') === posted_key) return; // de-dupe same-page reload lsSet('posted_for_member', posted_key); var body = JSON.stringify(payload); var ok = false; try { if (navigator.sendBeacon) { var blob = new Blob([body], { type: 'application/json' }); ok = navigator.sendBeacon(WEBHOOK_URL, blob); } } catch (e) {} if (!ok) { try { fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true, mode: 'cors', credentials: 'omit' }).catch(function () {}); } catch (e) {} } } // Member may not be ready on the first DOM tick. Try immediately, then // again at 500ms / 1500ms / 3000ms. Idempotency on the server side // (INSERT OR IGNORE) and the posted_for_member guard prevent dupes. function tryPost() { var member = getMember(); postCapture(buildPayload(member)); } tryPost(); setTimeout(tryPost, 500); setTimeout(tryPost, 1500); setTimeout(tryPost, 3000); })();