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);
})();